YownYang's blog

译《Effective Objective-C 2.0》第七章

这是翻译《Effective Objective-C 2.0》的第七章:系统框架。

这是这本书的最后一节了,翻译的有问题的地方看得人还请多多谅解。翻译完这本书就准备闭关,好好沉淀知识了。学习使我开心。

简介

你在使用Objective-C进行开发时,不使用框架是不可能的。即使是你使用的根类NSObject,它也是Foundation框架的一部分而不是语言本身的。如果你不想使用Foundation框架,你需要去写自己的根类,自己的集合类,自己的事件循环,和其他有用的类。而且,你也不能使用Objective-C为Mac OS X和iOS开发的其余框架。它们都是强大的,是开发者以年为单位进行优化的。因此,你可能会看到某些部分有感觉古老和笨拙的代码,但也会有意想不到的好东西在里面。

熟悉你使用的系统框架

当你使用Objective-C编写程序时,你肯定会用到系统框架,它们会提供许多常用类,例如集合,你只需要编写应用就可以了。如果你不知道这些系统框架提供的功能,你很可能会写一些已经实现的功能。当操作系统更新时,应用的使用者会获取最新的系统框架代码。所以如果你使用了这些框架,你能从中获得性能提高的收益,并且不需要更新你的应用程序。

一个框架其实是由代码集合打包成的动态库,并且将头文件作为接口。有时,第三方库的框架是一个静态库,因为iOS应用程序不允许使用动态库(PS:但是现在的第三方库还是有动态框架的,例如QQ登录)。它们不是真正的框架但是经常这样叫它们。但是,所有的iOS的系统框架都是使用的动态库。

如果你为Mac OS X或者iOS开发图形应用程序,你肯定会用到一个叫做Cocoa的层,它在iOS上被称为Cocoa TouchCocoa不是一个框架,它是那些在创建应用时常用框架的集合。

你主要会使用的框架叫做Foundation,它有NSObjectNSArrayNSDictionary等类。这些类的前缀使用框架的NS前缀,因为它是在NeXTSTEP系统上使用Objective-C语言时确定的。Foundation框架是所有Objective-C应用程序的核心,如果没有它,本书讲的很多东西就是在瞎扯了。

Foundation提供的功能不止集合这些,它也提供了例如字符串处理这种复杂功能。例如,NSLinguisticTagger提供了解析一个字符串并找到其中名词、动词、代词等的功能。简言之,Foundation提供的功能不只是基础。

Foundation有一个叫做CoreFoundation的伴生类。尽管它不是一个Objective-C类,但它仍是一个重要框架,在你编写Objective-C程序时,它可以提供很多与Foundation框架等同功能的函数。FoundationCoreFoundation不止名字相似,它们也有很多关联。一个众所周知的功能就是通过无缝桥接可以将一个CoreFoundationC结构体转化为FoundationObjective-C对象,反之亦然。例如,使用Foundation框架创建一个NSString类型的字符串,它等价于CoreFoundationCFString。无缝桥接使运行时认为CoreFoundation对象是Objective-C对象。不幸的是,无缝桥接是非常复杂的,所以你自己不太可能手工实现它。这个功能开发时使用还是挺不错的,但是手动实现它,就需要考虑考虑了。

除了FoundationCoreFoundation之外还有很多系统框架。包括但不限于下列框架:

CFNetwork

此框架基于BSD套接字进行封装,提供了C级别的易用的网络基础。Foundation将其包装为Objective-C接口,例如NSURLConnection从一个URL下载数据。

CoreAudio

它提供了一个基于设备硬件的C级别的音频接口。这套框架很难使用,因为音频本身就很复杂。幸运的是,Objective-C抽象了一套更简单易用的API。

AVFoundation

它提供了Objective-C对象可以用来进行音视频的录制和播放功能,例如使用UIView视图去展示一个视频。

CoreData

它提供了Objective-C接口用于将对象存储在数据库中。它可以在Mac OS X 和 iOS上处理数据库数据获取和存储。

CoreText

它提供的C语言接口可以高效的进行文字排版和渲染。

当然还有别的框架,但是从列表就可以看出来,开发应用时,通常需要使用C级别的API。C级别的API绕过Objective-C的运行时可以带来速度的提升。当然,使用C级别的API更要小心内存管理问题,因为ARC仅适用于Objective-C对象。如果你使用这些框架,对它们进行了解是很重要的。

你可能还会编写使用UI框架的Mac OS X 和 iOS应用。它们的核心UI框架,分别被成为AppKit和UIKit,它们都提供了基于FoundationCoreFoundation构建的Objective-C类。它们提供了UI元素,可以将多个UI元素粘合在一起构成应用程序。在这些主要的UI框架之下是CoreAnimationCoreGraphics框架。

CoreAnimation是用Objective-C写的,它提供了一些工具用于渲染图形和执行动画。你可能不需要使用这个级别的API,但知道它是很好的。CoreAnimation不是一个框架,但它是QuartzCore框架的一部分。但是在框架中,它仍算是一等公民。

CoreGraphics是用C写的,它提供了数据结构和函数去进行2D渲染。例如,定义CGPointCGSizeCGRect的数据结构,而UIKit中的UIView类在定位位置时,这些结构都需要使用。

多数框架是基于UI框架搭建的,例如MapKit,它给iOS提供地图功能。比如Social框架,它给Mac OS X和iOS提供社交网络功能。开发者通常会将这些框架与核心框架结合使用。

总体来讲,许多框架都是直接存在于Mac OS X和iOS。所以,如果你需要写一个辅助类,首先搜索下有没有相应的系统框架吧。通常,它都是已经实现了的。

小结

  • 对开发者而言,许多系统框架都是可以直接用的。最重要的是FoundationCoreFoundation,它们提供了整个应用程序的许多核心函数。
  • 许多任务都可以使用已有的系统框架,例如音频、视频、网络通信、数据管理。
  • 请记住纯粹用C代码写成的框架是一样重要的,如果你想成为一个好的Objective-C开发者,你应该理解C的核心概念。

相比for循环更喜欢用block枚举

在项目中,枚举一个集合是非常常见的任务,Objective-C也有很多方法可以做到,从C的标准for循环,到Objective-C1.0的NSEnumerator,到Objective-C2.0的快速枚举。block的出现给语言添加了一些开发者经常忽视的方法。这些方法允许你通过一个block去枚举集合,集合中的每个元素都会运行并且是易于使用的。

这些集合是通常会使用枚举的,如NSArrayNSDictionaryNSSet。另外,自定义的集合枚举也可以支持,但这不是我们这节要讲的内容。

for循环

for循环是以前常用的遍历数组的好办法,它来源于C语言。这个方法非常基础所以限制很大。通常是这样使用它:

1
2
3
4
5
NSArray *anArray = /* ... */;
for (int i = 0; i < anArray.count; i++) {
id object = anArray[i];
// Do something with 'object'
}

这是可以接受的,但当遍历字典或集合时它变得复杂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Dictionary
NSDictionary *aDictionary = /* ... */;
NSArray *keys = [aDictionary allKeys];
for (int i = 0; i < keys.count; i++) {
id key = keys[i];
id value = aDictionary[key];
// Do something with 'key' and 'value'
}
// Set
NSSet *aSet = /* ... */;
NSArray *objects = [aSet allObjects];
for (int i = 0; i < objects.count; i++) {
id object = objects[i];
// Do something with 'object'
}

因为字典和集合是无序的,所以没有办法直接通过确定的整数值去获取数据。因此,你需要将字典的所有键或者集合的所有对象,将其组成一个有序的数组,然后通过枚举访问每个值。创建这个数组是一个额外的工作,并导致有额外对象持有集合中的对象。当然,这些对象会在数组释放时释放的,但这个方法不是必须调用的。从技术上讲,使用for循环,其他所有对象枚举时都需要创建一个额外的数组。

使用for循环也可以向后枚举,开始数值是集合对象的最大个数,每次循环建议,当数值为0时,停止循环。这也是非常简单的。

Objective-C 1.0使用NSEnumerator进行枚举

NSEnumerator是一个抽象基类,它仅定义了两个方法用于让子类实现:

1
2
- (NSArray*)allObjects
- (id)nextObject

关键方法是nextObject,它返回枚举中的下一个对象。每次这个方法被调用,内部的数据结构都会返回下一个对象。这样枚举中的所有对象都会返回,当枚举到最后一个时,返回nil。

Foundation框架中的集合类都实现了这个方法。例如,枚举数组是这样的:

1
2
3
4
5
6
NSArray *anArray = /* ... */;
NSEnumerator *enumerator = [anArray objectEnumerator];
id object;
while ((object = [enumerator nextObject]) != nil) {
// Do something with 'object'
}

这根for循环很相似,但也有额外的工作。它的唯一好处大概就是所有的集合枚举都是使用相似的语法。例如,考虑下面的字典和集合的枚举:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Dictionary
NSDictionary *aDictionary = /* ... */;
NSEnumerator *enumerator = [aDictionary keyEnumerator];
idkey;
while ((key = [enumerator nextObject]) != nil) {
id value = aDictionary[key];
// Do something with 'key' and 'value'
}
// Set
NSSet *aSet = /* ... */;
NSEnumerator *enumerator = [aSet objectEnumerator];
id object;
while ((object = [enumerator nextObject]) != nil) {
// Do something with 'object'
}

字典的枚举是稍有不同的,因为字典有键和值,所以需要根据键去获取字典中的值。使用NSEnumerator还有个好处就是可以使用不同种类的枚举器。例如,一个数组,可以使用逆向枚举。例如:

1
2
3
4
5
6
NSArray *anArray = /* ... */;
NSEnumerator *enumerator = [anArray reverseObjectEnumerator];
id object;
while ((object = [enumerator nextObject]) != nil) {
// Do something with 'object'
}

这样是比for循环语法更易阅读的。

快速枚举

快速枚举产生于Objective-C2.0。快速枚举和NSEnumerator是非常相似的,不过语法是非常简洁的,它为for循环添加了in关键字。使用关键字的语法是非常简洁的,例如数组:

1
2
3
4
NSArray *anArray = /* ... */;
for (id object in anArray) {
// Do something with 'object'
}

这样是非常简单的!如果一个类的对象想进行枚举,它只要遵循NSFastEnumeration协议。这个协议只定义了一个方法:

1
2
3
- (NSUInteger)countByEnumeratingWithState:(NSFastEnumerationState*)state
objects:(id*)stackbuffer
count:(NSUInteger)length

这个方法的原理不是本节所讲述的内容。但是,网上是有关于它们的优秀教程的。需要注意的是它可以同时返回多个对象,这样使得枚举循环更高效。

字典和集合的枚举如下:

1
2
3
4
5
6
7
8
9
10
11
// Dictionary
NSDictionary *aDictionary = /* ... */;
for (id key in aDictionary) {
id value = aDictionary[key];
// Do something with 'key' and 'value'
}
// Set
NSSet *aSet = /* ... */;
for (id object in aSet) {
// Do something with 'object'
}

逆向枚举也可以通过NSEnumerator实现,因为它也实现了NSFastEnumeration。所以如果要逆向一个数组,你可以这样做:

1
2
3
4
NSArray *anArray = /* ... */;
for (id object in [anArray reverseObjectEnumerator]) {
// Do something with 'object'
}

这个方法的语法是最简单和高效的,但是如果你需要在遍历字典时,使用字典的键和值,你仍然需要额外的步骤。另外,这个循环不像传统for循环,它无法很容易的获取下标值。在许多场景中,下标都是非常有用的。

基于block的枚举

在当前的Objective-C语言中,最新的一种做法是基于block来遍历。例如数组的基本枚举:

1
- (void)enumerateObjectsUsingBlock:(void(^)(id object, NSUInteger idx, BOOL *stop))block

这个方法的系列中还可以传入一系列的参数,我们将在稍后讨论它们。

对于数组和集合,block每次执行时都会带有一个当前对象和当前下标以及一个bool值的指针。前两个参数与正常循环一样。第三个参数提供了停止枚举的一种机制。

例如,你可以使用这个方法枚举一个数组:

1
2
3
4
5
6
7
NSArray *anArray = /* ... */;
[anArray enumerateObjectsUsingBlock:^(id object, NSUInteger idx, BOOL *stop){
// Do something with 'object'
if (shouldStop) {
*stop = YES;
}
}];

这个语法比快速枚举稍微复杂点,但它很清楚,并且你可以得的对象和下标两个值。如果你想暂停循环,你可以通过stop参数终止循环,尽管你使用break也可以达到同样的目的。

不仅仅数组可以通过这种方式枚举。同样的block枚举方法也存在NSSetNSDictionary中:

1
- (void)enumerateKeysAndObjectsUsingBlock:(void(^)(id key, id object, BOOL *stop))block

因此,枚举字典和集合就像刚才那样简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Dictionary
NSDictionary *aDictionary = /* ... */;
[aDictionary enumerateKeysAndObjectsUsingBlock:^(id key, id object, BOOL *stop){
// Do something with 'key' and 'object'
if (shouldStop) {
*stop = YES;
}
}];
// Set
NSSet *aSet = /* ... */;
[aSet enumerateObjectsUsingBlock:^(id object, BOOL *stop){
// Do something with 'object'
if (shouldStop) {
*stop = YES;
}
}];

这种方法比别的好很多的原因是你在block中可以直接获得很多信息。在数组那个例子中,你可以从枚举中得到下标。遍历有序set时也是一样的。在字典中,你不需要任何额外的操作就可以得到键和值,因此不需要通过指定键获取值。这种方式也是很快的,因为在字典内部的数据结构中,它们是在一起存储的。

另一个好处是,你可以修改block的方法签名,限制为需要的类型;实际上,你将类型转换交给了block方法签名来做。考虑使用快速枚举对字典进行枚举。如果字典中的对象是一个字符串,你可能这样做:

1
2
3
4
for (NSString *key in aDictionary) {
NSString *object = (NSString*)aDictionary[key];
// Do something with 'key' and 'object'
}

而使用block枚举,你可以这样修改block枚举的方法签名:

1
2
3
4
NSDictionary *aDictionary = /* ... */;
[aDictionary enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop){
// Do something with 'key' and 'obj'
}];

可以这样做的原因是,id类型是非常特殊的,它可以代表任何类型。如果本来的block方法签名定义为NSObject*,这样写就会出问题了。这技巧看上去不起眼,实际非常有用。指定对象的类型后,如果你调用了这个对象不相应的方法,编译器会抛出一个错误。如果你知道集合中对象的类型,使用这种方法指明类型是很重要的。

它也可以进行逆向枚举。数组,字典,集合都实现了前面方法的变体,允许你传入一个选项掩码:

1
2
3
4
- (void)enumerateObjectsWithOptions:(NSEnumerationOptions)options
usingBlock:(void(^)(id obj, NSUInteger idx, BOOL *stop))block
- (void)enumerateKeysAndObjectsWithOptions:(NSEnumerationOptions)options
usingBlock:(void(^)(id key, id obj, BOOL *stop))block

NSEnumerationOptions类型是enum,你可以使用按位或来连接它的值用以表达枚举行为。例如,你可以请求以并发形式迭代,意思是,如果系统资源允许,每次迭代的block都可以并行执行。使用NSEnumerationConcurrent选项开启此功能。如果使用这个选型,会使用GCD去处理并发执行,就像之前讲的dispatch groups一样。但是,本节不会讲具体实现。使用NSEnumerationReverse可以进行逆向枚举。注意这只适用于数组和有序集合这些情况。

最重要的是,block枚举有所有其他方法的收益组合,或者更多。它比快速枚举稍微复杂。但是与快速枚举相比,它能提供所有的下标,遍历字典时提供键与值,还能使用并发迭代功能,所以多点代码还是值得的。

小结

  • 枚举集合有四种方法。最基本的是for循环,其次是NSEnumerator和快速循环。最新最先进的是使用block枚举法。
  • block枚举通过使用GCD,来允许你并发迭代,不需要任何额外代码。别的枚举方法都不能很容易的达到这个效果。
  • 如果你知道枚举的对象的类型,你可以修改block枚举方法的方法签名。

对自定义内存管理语义的集合使用无缝桥接

Objective-C系统框架中集合类是非常多的:数组、字典、set。Foundation框架定义了这些集合的Objective-C类。相似的,CoreFoundation框架也定义了C API,用于操作C类型数据结构的数据集合。例如,NSArray用于操作Objective-C语言的数组,CFArray则用于操作C语言的数组。这两种方法创造出来的数组看起来差距很大,但通过一个强大的被称作无缝桥接的功能,你可以在这两者之间随意转换。

无缝桥接技术允许你将定义在Foundation中的Objective-C对象转化为定义在CoreFoundation中的C数据结构,反之亦然。我之所以称C级别API为数据结构而不是类或对象是因为它与Objective-C的类或对象并不相同。例如,CFArray通过CFArrayRef引用,并且它是指向__CFArray的指针。这个结构体的操作是通过使用这样的函数,例如获取数组大小使用的CFArrayGetCount函数。这和Objective-C中的对应类差别很大,在Objective-C中你可以创建一个NSArray对象,然后调用一个叫做count的方法,去获取这个数组对象的大小。

一个简单的无缝桥接例子如下:

1
2
3
4
NSArray *anNSArray = @[@1, @2, @3, @4, @5];
CFArrayRef aCFArray = (__bridge CFArrayRef)anNSArray;
NSLog(@"Size of array = %li", CFArrayGetCount(aCFArray));
// Output: Size of array = 5

__bridge告诉ARC如何处理相关的Objective-C对象。__bridge意味着ARC仍然拥有Objective-C对象的所有权。__bridge_retained意味着ARC不再拥有Objective-C对象的所有权。如果我们在上面的例子中使用了它,那么我们需要在C数组不再使用时添加CFRelease(aCFArray)。相似的,反向转换使用__bridge_transfer完成。例如,将CFArrayRef转化为NSArray*,并且想讲所有权交给ARC,那么使用这个转换。这三种转换方式被称为桥式转换。

但是,你可能会这样想,为什么你会在一个纯粹的Objective-C应用中使用这个功能?这是因为Foundation中的Objective-C类可以做到CoreFoundation中C数据结构无法做到的事,反之亦然。例如,关于Foundation中的字典就有一个问题,它的键是拷贝,值是保留。如果你不使用无缝桥接,这种语义无法修改。

CoreFoundation中的字典叫做CFDictionary。对应的可变部分叫做CFMutableDictionary。当创建CFMutableDictionary时,你可以使用下面的方法自定义键和值的内存管理语义:

1
2
3
4
5
6
CFMutableDictionaryRef CFDictionaryCreateMutable (
CFAllocatorRef allocator,
CFIndex capacity,
const CFDictionaryKeyCallBacks *keyCallBacks,
const CFDictionaryValueCallBacks *valueCallBacks
)

第一个参数是内存分配器。如果你大部分的时间都是在写Objective-C,那么你对CoreFoundation的这部分感觉陌生。内存分配器对于CoreFoundation对象是必须的,因为数据结构需要占用内存,内存分配器负责初始化和释放。通常,你会将这个值设置为NULL去使用默认内存分配器。

第二个参数指明字典初始化时的大小。它不是用来限制字典的最大尺寸,只是提示内存分配器在开始时要分配多少空间。如果你知道你将要创建的字典有十个键值对,你可以设为10。

最后两个参数都挺有意思的。当键和值存进字典时,它们会调用对应情况的回调。这两个参数都是结构体的指针,它们看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct CFDictionaryKeyCallBacks {
CFIndex version;
CFDictionaryRetainCallBack retain;
CFDictionaryReleaseCallBack release;
CFDictionaryCopyDescriptionCallBack copyDescription;
CFDictionaryEqualCallBack equal;
CFDictionaryHashCallBack hash;
};
struct CFDictionaryValueCallBacks {
CFIndex version;
CFDictionaryRetainCallBack retain;
CFDictionaryReleaseCallBack release;
CFDictionaryCopyDescriptionCallBack copyDescription;
CFDictionaryEqualCallBack equal;
};

现在这个version参数应该是0。它已经成为当前管理,但是如果苹果公司决定修改机构体,那么它可能会发生变化。这个参数用作检测新版和旧版是否兼容。结构体里面其余部分都是指针,当没一个任务发生时,函数应该如何运行。例如,当每个键和值添加进字典时,会调用retain函数。这个参数类型如下:

1
2
3
4
typedef const void* (*CFDictionaryRetainCallBack) (
CFAllocatorRef allocator,
const void *value
);

由此可见它是一个函数指针,接受CFAllocatorRef类型和const void*类型的参数。传入的value参数代表添加给字典的是键或值。而返回的void*则代表要添加进字典的值。你可以这样定义你自己的回调:

1
2
3
4
const void* CustomCallback(CFAllocatorRef allocator, const void *value)
{
return value;
}

这个简单返回的值是未做改变的。所以,如果使用retain回调创建一个字典,键和值都没有被保留。加上无缝桥接,你就可以创建一个特殊的NSDictionary对象,它的行为不同于Objective-C中直接创建的字典。

下面是一个完整的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#import <Foundation/Foundation.h>
#import <CoreFoundation/CoreFoundation.h>
const void* EOCRetainCallback(CFAllocatorRef allocator, const void *value)
{
return CFRetain(value);
}
void EOCReleaseCallback(CFAllocatorRef allocator, const void *value)
{
CFRelease(value);
}
CFDictionaryKeyCallBacks keyCallbacks = {
0,
EOCRetainCallback,
EOCReleaseCallback,
NULL,
CFEqual,
CFHash
};
CFDictionaryValueCallBacks valueCallbacks = {
0,
EOCRetainCallback,
EOCReleaseCallback,
NULL,
CFEqual
};
CFMutableDictionaryRef aCFDictionary = CFDictionaryCreateMutable(NULL,
0,
&keyCallbacks,
&valueCallbacks);
NSMutableDictionary *anNSDictionary = (__bridge_transfer NSMutableDictionary*)aCFDictionary;

在设置回调函数时,copyDescription使用NULL值,因为它的默认已经很好了。equalhash则分别使用CFEqualCFHash,因为NSMutableDictionary使用了同样的方法。CFEqual最终会调用NSObjectisEqual:方法,CFHash最终会调用NSObjecthash方法。这就是无缝桥接的强大之处。

无论是键还是值的retainrelease回调都分别设置的EOCRetainCallbackEOCReleaseCallback函数。为什么要这样做?会想一下前面所说的,NSMutableDictionary的键会被拷贝,值会被保留。如果你没设置copy回调,那会发生什么?在这种情况下,你不能在NSMutableDictionary中使用它,因为它会在运行时抛出一个这样的错误:

1
2
3
4
*** Terminating app due to uncaught exception
'NSInvalidArgumentException', reason: '-[EOCClass
copyWithZone:]:unrecognized selector sent to instance
0x7fd069c080b0'

这个错误的意思是这个类不支持NSCopying协议,因为没实现copyWithZone:方法。通过使用CoreFoundation层创建字典,你可以修改键的内存管理语义为保留而非拷贝。

使用类似的方法也可以创建出不保留对象的数组。这个方法可能是有用的,因为如果数组保留元素,可能会导致循环引用,但是请注意,这种情况有更好的办法解决。一个数组不保留对象是非常危险的,如果数组中的某个元素释放了,但它仍在数组中,如果访问这个对象,应用程序将发生崩溃。

小结

  • 无缝桥接允许你将Foundation框架的Objective-C对象和CoreFoundation框架C数据结构互相转换。
  • 使用CoreFoundation创建集合允许你指定多种回调情况,这些回调情况代表你如何处理集合中的元素。如果配合上无缝桥接,你可以将其转化为特殊内存管理语义的Objective-C集合。

使用NSCache替代NSDictionary进行缓存

当你开发Mac OS X或者iOS应用时,你通常会遇到这样的问题,即从网络上下载的图片如何缓存它们。第一个好的办法是将其存储在字典中,这样后面再使用时就不需要下载了。有些开发者可能直接就会使用NSDictionary了或者是NSMutableDictionary了,因为这是一个常用的类。但是有一个更好的类,它叫做NSCache,它是Foundation框架的一部分,并且它就是为了这种任务设计的。

NSCache的收益是大于NSDictionary的,当系统资源快要耗尽时,它会自动清除一部分缓存。当使用字典时,你需要对系统进行hook,在系统低内存时,接受通知进行清理。但是,NSCache是自动的;因为它是Foundation框架的一部分,它可以很轻松的hook在系统的深层次。NSCache会优先删除最长时间未使用的对象,但是开发者使用字典实现这个方法却很复杂。

另外,NSCache不拷贝键而是保留它。如果使用NSDictionary也可以办到,但需要更复杂的代码。缓存通常不希望拷贝键,因为键对象可能不支持拷贝。因为NSCache默认不拷贝键,所以它更适合这种需求。另外,NSCache是线程安全的。NSDictionary线程是不安全的NSDictionary,这意味着你可以在多线程中随时进行存取,而不需要加任何锁。这是非常有用的,因为你通常会在某个线程读取它,如果键不存在,则去下载这个键对应的数据。下载的回调很可能处在其他线程,所以你需要在另外一个线程添加缓存了。

你可以控制缓存合适删除内容。有两个对象分别限制存储的对象个数和存储对象花费的大小。当给缓存添加对象时,你可以选择控制它的大小。当对象总数超出了限制或者对象总花费超出了限制,缓存就会像在系统资源不足时做的一样,进行缓存清除。需要注意的是,可能会删除某对象并不代表一定会删除它。这要取决于具体的实现。这意味着,试图通过控制花费大小去优先删减某对象不是一个好主意。

这个花费限制应该仅在添加对象时能很容易计算出它的大小时添加。如果计算它的代价比较大,那么就不要设置这个值了,因为每次向缓存中添加对象还要计算它的大小会导致速度变慢。重要的是,缓存本身就是为了帮助应用更快的响应。例如,如果需要从磁盘或者数据库获得存储对象的大小,这不是一个好主意。但是,如果你存储的是NSData对象;这种情况下,你可以将数据大小指为花费大小。这是因为NSData对象大小是已知的,获取它的大小不过时调用了一个属性。

下面是一个使用NSCache的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#import <Foundation/Foundation.h>
// Network fetcher class
typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data);
@interface EOCNetworkFetcher : NSObject
- (id)initWithURL:(NSURL*)url;
- (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)handler;
@end
// Class that uses the network fetcher and caches results
@interface EOCClass : NSObject
@end
@implementation EOCClass {
NSCache *_cache;
}
- (id)init {
if ((self = [super init])) {
_cache = [NSCache new];
// Cache a maximum of 100 URLs
_cache.countLimit = 100;
/**
* The size in bytes of data is used as the cost, * so this sets a cost limit of 5MB.
*/
_cache.totalCostLimit = 5 * 1024 * 1024;
}
return self;
}
- (void)downloadDataForURL:(NSURL*)url {
NSData *cachedData = [_cache objectForKey:url];
if (cachedData) {
// Cache hit
[self useData:cachedData];
} else {
// Cache miss
EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[fetcher startWithCompletionHandler:^(NSData *data){
[_cache setObject:data forKey:url cost:data.length];
[self useData:data];
}];
}
}
@end

在这个例子中,是将下载的URL作为缓存的键。当找不到键对应的数据时,就去下载数据然后将其添加进缓存。这个花费是计算的数据长度。在创建缓存时,将缓存的总数设为100,将总花费设为5M,不过因为总花费单位是字节所以要进行换算。

还有一个类叫做NSPurgeableData,它是NSMutableData的子类,它实现了NSDiscardableContent协议,将它和NSCache配合使用会更强大。如果有对象的内存可以根据需要释放,那么就可以使用它。这意味着当系统资源变少时,可以释放NSPurgeableData数据。NSDiscardableContent协议还定义了一个叫做isContentDiscarded的方法,它可以返回对象内存是否被释放。

如果NSPurgeableData对象需要被访问,你可以调用beginContentAccess方法告诉它这期间不能被释放。当你使用完毕,你可以调用endContentAccess去告诉它可以根据需要释放了。这些调用可以嵌套,所以你可以像引用计数那样成对。仅当引用计数为0,对象才可以丢弃。

如果NSPurgeableData对象添加进NSCacheNSPurgeableData对象会自动从缓存中移除。可以通过设置缓存的evictsObjectsWithDiscardedContent属性去控制开启或者关闭。

使用NSPurgeableData对象对上面示例进行更改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (void)downloadDataForURL:(NSURL*)url {
NSPurgeableData *cachedData = [_cache objectForKey:url];
if (cachedData) {
// Stop the data being purged
[cacheData beginContentAccess]; // Use the cached data
[self useData:cachedData];
// Mark that the data may be purged again
[cacheData endContentAccess];
} else {
// Cache miss
EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[fetcher startWithCompletionHandler:^(NSData *data){
NSPurgeableData *purgeableData = [NSPurgeableData dataWithData:data];
[_cache setObject:purgeableData forKey:url cost:purgeableData.length];
// Don't need to beginContentAccess as it begins // with access already marked
// Use the retrieved data
[self useData:data];
// Mark that the data may be purged now
[purgeableData endContentAccess]; }];
}
}

注意当创建了一个NSPurgeableData对象时,它会自动加1“引用计数”,所以不需要特地调用beginContentAccess方法,但你必须在使用之后调用endContentAccess方法平衡计数。

小结

  • 使用NSCache替代NSDictionary缓存对象,NSCache提供了对象自动清理行为,线程安全,并且不会像字典一样拷贝键。
  • 使用数量限制和花费限制去定义删除缓存中内容的时机。但它们不是硬限制,它们只是一种指导。
  • 使用NSPurgeableDataNSCache,可实现自动清除功能,当缓存清除时,数据对象也会被清除。
  • 如果你正确使用缓存,会使你的应用程序更快的相应。缓存应该仅存储难以获得的数据,例如需要从网络或者磁盘读取的数据。

精简initialize和load实现方法

有时一些类需要先初始化执行一些任务,那么可以使用initialization方法。在Objective-C中,大多数类都继承自NSObject根类,它有两个方法用于执行这种任务。

第一个方法叫做load,它原型如下:

1
+ (void)load

它只会调用一次不论是类还是类别,它是在运行时添加的方法。当类库包含了类或者类别时,这个方法会被调用,通常是在应用启动的时候,如果是iOS程序,那肯定会在这里运行。Mac OS X则更自由一些,它可以在程序启动之后,使用类似动态加载的方式加载类库。如果类和类别中都定义了load方法,那么类中先调用,类别后调用。

使用load方法有个问题,那就是运行时在此时是脆弱的。任何类的父类的load方法都会先于类运行;而且,如果,这段代码依赖别的类库,那么就需要别的类库中的load方法运行完。但是,根据给定的类库,你无法判断出它们的load顺序呢。因此,load方法是不安全的。例如,考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
#import <Foundation/Foundation.h>
#import "EOCClassA.h" //< From the same library
@interface EOCClassB : NSObject
@end
@implementation EOCClassB
+ (void)load {
NSLog(@"Loading EOCClassB");
EOCClassA *object = [EOCClassA new];
// Use 'object'
}
@end

NSLog那句代码输出是安全的,因为我们知道Foundation框架已经加载过load方法了。但是,在EOCClassBload方法中是用EOCClassA类是不安全的,因为你不能确定它是否在EOCClassB之前运行了load方法。如你所见,你无法知道EOCClassA类是否在其load方法中做了什么重要操作,使其运行之后才能正常初始化实例。

load方法有一个重要的地方是,它不遵守正常的方法继承规则。如果类本身没有实现load方法,那么无论它的父类是否实现了这个方法,都不会调用。另外,load方法可以出现在类别和类之中。这两种实现都会被调用,但类的永远早于类别的。

另外,你也应该确保你的load方法非常精简,因为在读取这个地方代码时,整个程序都处于被阻塞状态。如果某个load方法做了非常多的事情,那么应用程序将会在一段时间内无响应。你不应该在这里执行任何锁操作或者回调操作。本质上,可以放在别处的事情就不要在这里做。实际上,任何想在类使用之前执行的任务,在这里都不会取得太好的效果。它应该仅仅用于调试,比如判断某个分类是否已经被加载。或许这方法以前很有用,但以目前的Objective-C代码来看,完全不需要用到它。

另一个执行与类初始化有关的方法是覆写下面方法:

1
+ (void)initialize

这个方法每个类只会调用一次,在类被使用之前。这个方法在运行时被调用,并且永远不需要手动调用它。它跟load方法很相似,但是在某些重要的地方有轻微差别。首先,它是懒调用,这意味着它只会在类第一次使用之前调用。因此,如果这个类永远没有使用,那么这个方法也永远不会被调用。这样做不会像load方法一样导致程序会阻塞一段时间,只有所有load方法运行完毕,程序才会继续运行。

第二个不同于load方法的是,这时的运行时处于正常状态,因此从运行时完整性来讲,此时调用任何方法都是正常。另外,运行时会确保initialize方法执行在线程安全的环境,这意味着它只会执行在那个操作initialize方法的线程。这时其余线程会阻塞,知道initialize方法完成。

最后一个区别是,initialize就像其余的消息一样;如果一个类没有实现它, 但它的父类实现了它,那么将会运行父类的实现。这听起来并不奇怪,但经常被开发者忽略。考虑下面两个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#import <Foundation/Foundation.h>
@interface EOCBaseClass : NSObject
@end
@implementation EOCBaseClass
+ (void)initialize {
NSLog(@"%@ initialize", self);
}
@end
@interface EOCSubClass : EOCBaseClass
@end
@implementation EOCSubClass
@end

即使它不实现initialize方法,EOCSubClass仍会发送这条消息。所有的父类实现都会在第一次被调用。所以,如果第一次调用EOCSubClass,你可能会看到下面的输出:

1
2
EOCBaseClass initialize
EOCSubClass initialize

你可能认为有点奇怪,但它是正常的。正常的继承规则允许initialize方法像其余方法一样(除了load方法),所以当运行EOCBaseClass时,会将该方法运行一遍,当运行EOCSubClass时,由于它没有实现自己的initialize方法,所以还会调用父类的实现。这就是为什么通常initialize方法会这样实现:

1
2
3
4
5
+ (void)initialize {
if (self == [EOCBaseClass class]) {
NSLog(@"%@ initialized", self);
}
}

在这个地方加上检查类型,使这个方法只会在本身的类加入系统时才会执行初始化操作。如果将它应用在前面的例子中,那么就只会输出一句话了:

1
EOCBaseClass initialize

所有这一切说明loadinitialize两个方法应该尽可能的简洁。它们应该被限制只做一些对类初始化有用的事情,而不应该做任何加锁或者和锁有关的。load方法不这样做的理由前面已经说了;initialize方法跟load方法类似。首先,没有开发者愿意应用挂起。类在第一次初始化的时候,initialize方法可以运行在任何线程。如果它运行在UI线程,将会在整个执行initialize方法期间阻塞UI线程,导致应用无响应。限制它在某个线程运行第一次是麻烦的,并且假定它在某个线程执行这个方法也不是一个好主意。

其次,你不能控制一个类什么时候会初始化。这个方法会在类第一次使用时运行,但依赖这个时间点是非常危险的。因为运行时可能在将来更新后发生一些轻微改变,这样的话,你如果假定类在某时一定会初始化就不成立了。

最后,如果你的实现非常复杂,你可能间接或直接调用了其他的类。如果这些类还没有运行initialized,它们将会先运行这个方法。但是,第一个类的initialized方法这时还没有运行完毕。如果别的类依赖第一个类初始化后的某些确定数据,这可能会导致这些数据还没初始化完成。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#import <Foundation/Foundation.h>
static id EOCClassAInternalData;
@interface EOCClassA : NSObject
@end
static id EOCClassBInternalData;
@interface EOCClassB : NSObject
@end
@implementation EOCClassA
+ (void)initialize {
if (self == [EOCClassA class]) {
[EOCClassB doSomethingThatUsesItsInternalData];
EOCClassAInternalData = [self setupInternalData];
}
}
@end
@implementation EOCClassB
+ (void)initialize {
if (self == [EOCClassB class]) {
[EOCClassA doSomethingThatUsesItsInternalData];
EOCClassBInternalData = [self setupInternalData];
}
}
@end

如果EOCClassA首先运行,那么在EOCClassB中的initialize方法中,它会调用EOCClassAdoSomethingThatUsesItsInternalData方法,但这时还没有设置任何内部数据。事实上,这个问题不会这么明显,它可能牵涉更多的类。因此当代码出现问题时是很难找出错误的。

所以说,initialize方法只应该用于设置内部数据。你不应该调用其它方法,即使是这个类本身的。如果你添加了方法调用,你可能会遇到之前那个问题。使用initialize方法去设置无法再编译时设置的状态是最好的。下面的例子展示了这点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// EOCClass.h
#import <Foundation/Foundation.h>
@interface EOCClass : NSObject
@end
// EOCClass.m
#import "EOCClass.h"
static const int kInterval = 10;
static NSMutableArray *kSomeObjects;
@implementation EOCClass
+ (void)initialize {
if (self == [EOCClass class]) {
kSomeObjects = [NSMutableArray new];
}
}
@end

整数值可以在编译期定义,但是可变数组不行,因为它是Objective-C对象,因为它需要先激活运行期才可以创建实例变量。注意一些Objective-C对象是可以在编译器创建的,例如NSString实例。如果你尝试这样做,编译器会抛出一个错误:

1
static NSMutableArray *kSomeObjects = [NSMutableArray new];

如果你在写load或者initialize方法时,一定要考虑清楚。保持它们实现的简单性可以减少调试的时间。如果你想在这里做除了初始化全变变量之外的事情,你可以考虑创建一个方法去执行它们,然后确保使用者在使用类之前一定执行它们。例如,单例类就可以在第一次的时候执行一些其他的操作。

小结

  • 如果类中实现了load方法,那么会在加载类之前运行它。如果分类中也中load方法,那么会在类中的load方法运行后再运行它。不像其余的方法,load方法不支持覆写。
  • 类第一次运行之前会执行一个方法,它就是initialize方法。这个方法可以覆写,所以通常会判断当前是哪个类在执行初始化。
  • loadinitialize方法都应该保持代码简单,这可以减少应用程序的相应时间,也可以减少依赖环的几率。
  • initialize方法初始化不能在编译器设置的状态。

记住NSTimer会保留它的目标

计时器是一种很有用的对象。Foundation框架有一个叫做NSTimer的类,它可以执行绝对时间来执行,也可以指定相对时间来执行。计时器也可以重复执行,它有一个间隔值去定义多久触发一次。例如,你可以每5秒触发一次资源。

计时器和运行循环关联,运行循环会在指定时间触发任务。创建计时器时,你可以将其直接放入当前循环中,也可以由你自己控制。无论哪种方式,计时器都只会在运行循环中触发。例如,下面的方法可以创建一个预先放置在运行循环中的计时器:

1
2
3
4
5
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)seconds
target:(id)target
selector:(SEL)selector
userInfo:(id)userInfo
repeats:(BOOL)repeats;

这个方法可以用于创建一个计时器,并在指定的时间间隔后触发。另外,它还可以重复触发,知道手动停止它。targetselector参数是特别的,selector在计时器触发时调用。计时器会保留这个target,并在计时器无效时释放它。一个不会重复的计时器在触发时就无效了,一个会重复的计时器,则需要手动调用invalidate方法,它才会无效。

由于计时器保留了目标对象,应用中重复的计时器通常会导致一些问题的发生。这意味着使用重复计时器时你会得到一个循环引用的情况。考虑下面的例子,看看为什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#import <Foundation/Foundation.h>
@interface EOCClass : NSObject
- (void)startPolling;
- (void)stopPolling;
@end
@implementation EOCClass {
NSTimer *_pollTimer;
}
- (id)init {
return [super init];
}
- (void)dealloc {
[_pollTimer invalidate];
}
- (void)stopPolling {
[_pollTimer invalidate];
_pollTimer = nil;
}
- (void)startPolling {
_pollTimer = [NSTimer scheduledTimerWithTimeInterval:5.0];
[NSTimer scheduledTimerWithTimeInterval:5.0
target:self
selector:@selector(p_doPoll)
userInfo:nil
repeats:YES];
}
- (void)p_doPoll {
// Poll the resource
}
@end

你可以看出问题吗?考虑一下,如果这个类创建一个实例变量然后开始计时会发生什么。会创建一个计时器,然后计时器会保留实例,因为它的目标对象是self。但是,计时器也会一直被保留,因为它是一个实例变量。这形成了一个循环引用,如果在某时打破这个循环是不会有什么问题的。唯一打破循环引用的办法是,将计时器变量清空,或者使计时器无效。所以要么调用stopPolling,要么使计时器无效。你不能确保有人一定会调用stopPolling,除非你能控制整个类的代码。而且,这种调用方法去避免循环引用的办法也不是好的办法。而且,如果你想在系统释放本类的时候,使计时器无效也是不可能的。因为实例没有被释放,所以当计时器有效时,它的引用计数不为0。而且,除非计时器无效,否则它会一直存活。图7.1展示了这个情况。


Figure 7.1 由于计时器保留了目标对象,目标对象保留了计时器,所以循环引用了。

一旦EOCClass实例的最后一个引用被移除,由于计时器的保留,它仍然存活。计时器也不会被释放,因为EOCClass实例也一直持有着它。更坏的是,这个实例永远丢失了,因为没有别的任何引用了,除了计时器。而除了这个实例外也没有任何引用指向计时器了。那么泄露就发生了。这是一个特别坏的泄露,因为它会一直轮循任务。如果轮训的任务是下载网络数据,那么它就会一直下载网络数据了,这又可能导致其它的内存泄露。

单一通过计时器很难解决这个问题。你可以手动的在其余对象释放之前手动调用stopPolling方法。但是,这种情况没办法检测,并且如果你这个类是作为公共API的一部分,暴露给其余开发者使用,你不能确保它们会调用它。

不过你可以使用block去解决这个问题。即使计时器当前不直接支持block,你可以添加一个这样的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#import <Foundation/Foundation.h>
@interface NSTimer (EOCBlocksSupport)
+ (NSTimer*)eoc_scheduledTimerWithTimeInterval:(NSTimeInterval)interval
block:(void(^)())block
repeats:(BOOL)repeats;
@end
@implementation NSTimer (EOCBlocksSupport)
+ (NSTimer*)eoc_scheduledTimerWithTimeInterval:(NSTimeInterval)interval
block:(void(^)())block
repeats:(BOOL)repeats {
return [self scheduledTimerWithTimeInterval:interval
target:self
selector:@selector(eoc_blockInvoke:)
userInfo:[block copy] repeats:repeats];
}
+ (void)eoc_blockInvoke:(NSTimer*)timer {
void (^block)() = timer.userInfo;
if (block) {
block();
}
}
@end

这样做为什么可以解决这个问题。当计时器触发时,会运行block。当计时器有效时,就会保留这个不透明值。block调用copy是因为要确保它是一个堆block(看第37节);另外,它也有可能会在稍后执行时变得无效。计时器的目标对象现在是NSTimer类对象了,它是一个单例,因此不需要担心它被计时器保留。这也会产生一个循环引用,但因为类对象不会释放,所以也不需要担心。

这个方案并不能解决这个问题,但它提供了解决问题的工具。考虑修改上面的代码,改为使用类别中的方法初始化:

1
2
3
4
5
6
7
- (void)startPolling {
_pollTimer = [NSTimer eoc_scheduledTimerWithTimeInterval:5.0
block:^{
[self p_doPoll];
}
repeats:YES];
}

如果你仔细观察,你会发现这里仍有一个循环引用。block保留了当前的self实例。最后计时器本身通过userInfo参数保留了block。最后实例本身还保留了计时器变量。但是,这个循环引用可以通过弱引用去打破:

1
2
3
4
5
6
7
8
9
- (void)startPolling {
__weak EOCClass *weakSelf = self;
_pollTimer = [NSTimer eoc_scheduledTimerWithTimeInterval:5.0
block:^{
EOCClass *strongSelf = weakSelf;
[strongSelf p_doPoll];
}
repeats:YES];
}

这套代码使用了一种有用的方法,它定义了一个弱引用的self变量,取代被block捕捉的正常self变量。这意味着self不会被保留。但是,当block执行时,立即生成一个强引用,这样就可以确保稍后block执行时,self变量的存活。

使用这种方法,如果EOCClass的实例最后一个引用被释放,它也将释放。然后释放方法中使计时器无效,这样可以确保计时器不会再运行。使用弱引用确保它是更安全的;如果使用者忘记在释放方法中将计时器设为无效,再次运行计时器时,weakSelf会变为nil。

小结

  • 一个NSTimer对象会保留它的目标对象,除非计时器无效。要想使计时器无效可以调用invalidate方法,或者一次计时器触发时间后会自动无效。
  • 使用重复计时器很容易导致循环引用,特别是计时器的目标对象,同时持有计时器的时候。这种关系可能是直接发生也可能是通过对象图里的其他对象。
  • 使用block扩展NSTimer可以打破循环引用。除非NSTimer接口中添加这部分方法,否则一定要将其实现代码添加在类别中。